Skip to content

Code mode: make packaged builds work via managed engine provisioning - #625

Merged
arkml merged 11 commits into
devfrom
feat/code-mode-managed-engines
Jun 17, 2026
Merged

Code mode: make packaged builds work via managed engine provisioning#625
arkml merged 11 commits into
devfrom
feat/code-mode-managed-engines

Conversation

@Gagancreates

Copy link
Copy Markdown
Collaborator

Problem

Code mode was broken in every packaged release. The Claude/Codex ACP adapters are spawned as separate node <entry> processes resolved at runtime, but esbuild can't inline a dynamic spawn target and Forge strips the workspace node_modules — so packaged builds threw Cannot find module '@agentclientprotocol/...'. It only worked in dev (pnpm symlinks present).

The obvious fix — bundling the native engines — adds ~400 MB per installer (one claude + one codex binary, ~200 MB each).

Approach

Don't bundle the engines. Provision them on demand, the way Conductor does:

  • Stage the ACP adapters (JS only, ~16 MB) into .package/acp/node_modules and exempt .package from the node_modules ignore rule, so packaged builds can resolve + spawn them.
  • Provision engines into ~/.rowboat/engines/<agent>/<version>/ by downloading the per-platform package from npm at the exact version the adapter was built against (so the ACP handshake is always compatible), verifying its sha512 integrity, and extracting it. Engine versions/URLs/hashes live in a committed manifest, regenerated from the adapters on a bump.
  • Point the adapters at the provisioned binary via CLAUDE_CODE_EXECUTABLE / CODEX_PATH.

Result: installer stays ~390 MB (vs ~790 MB bundled), and code mode works out of the box.

Explicit "Enable" UX

Code mode requires the user to explicitly enable an agent — no surprise mid-chat download:

  • Settings → Code Mode: each agent shows Enable (downloads the engine with a live % indicator) → Ready. Requirement stated explicitly: the agent must be enabled (engine) and signed in (claude login / codex login — we reuse the saved credential).
  • Chat path never auto-downloads: if an agent isn't enabled, it fails with a clear "Enable it in Settings → Code Mode" message.

Robustness

  • Login-shell PATH grafting so the engine finds gh / git / etc. on Finder launches (launchd strips PATH).
  • Startup deadline (60s) so a wedged engine errors clearly instead of hanging; the adapter is disposed on failure.
  • Version pruning — superseded engine versions are removed after a successful install.

Notes

  • Signing/notarization in forge.config.cjs is commented out for local/unsigned builds; uncomment (with APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID) for a signed release.
  • CODE_MODE_ENGINES_PLAN.md documents the design and decisions.

Testing

  • macOS arm64: verified end-to-end — engines provision + run, both adapters complete the ACP initialize handshake from the packaged .app, and a full prompt turn returns a model reply.
  • Windows: engine tarball layouts (claude.exe, codex.exe, rg.exe, vendor paths) and extraction validated against my locator (provisioning path structurally verified; not yet run on a Windows machine).
  • Linux: covered by the manifest; not yet run.

Follow-ups (not in this PR)

  • CI smoke matrix to run the packaged app per-OS against a provisioned engine.
  • In-app sign-in for users with no existing claude/codex credentials.

…visioning

Packaged builds could never run code mode: the Claude/Codex ACP adapters are
spawned as separate `node <entry>` processes resolved at runtime, but esbuild
can't inline a dynamic spawn target and Forge strips the workspace node_modules,
so every release threw `Cannot find module '@agentclientprotocol/...'`. Dev
worked only because of the pnpm symlink.

Rather than bundle the ~400 MB of native engines (one claude + one codex binary
per OS), provision them on demand:

- forge.config.cjs: stage the two ACP adapters + their JS dependency closure into
  .package/acp/node_modules (npm-style nested layout, native engines skipped),
  exempt .package from the node_modules ignore rule, and only sign/notarize when
  APPLE_ID is set so unsigned local/CI builds can package.
- agents.ts: resolve the adapter from the staged location first (node_modules
  fallback in dev); provision the pinned engine and point the adapter at it via
  CLAUDE_CODE_EXECUTABLE / CODEX_PATH. No dependence on a user's global install.
- engine-provisioner.ts: ensureEngine() downloads the per-platform engine package
  from npm AT THE EXACT VERSION THE ADAPTER WAS BUILT AGAINST, verifies its sha512
  integrity, extracts atomically into ~/.rowboat/engines/<agent>/<version>/, and
  caches it. Version-pinning keeps the ACP handshake compatible.
- engine-manifest.ts + scripts/gen-engine-manifest.mjs: committed manifest of
  tarball URLs + integrity for all platforms, regenerated from the adapters'
  pinned versions on a bump.

Verified on macOS arm64: both engines provision and run, and both adapters
complete the ACP initialize handshake from the packaged .app against the
provisioned engines. Installer drops from ~790 MB to 390 MB.
…t download

Code mode now requires the user to explicitly enable an agent before use, instead
of silently downloading a ~200 MB engine on the first chat message.

- Settings → Code Mode: each agent shows "Not enabled" + an Enable button that
  downloads its engine with a live progress indicator (download % → verify →
  install), then flips to "Engine ready". Driven by a new codeMode:provisionEngine
  IPC call + a codeMode:engineProgress push channel. The section now states the
  prerequisite explicitly: the agent must be installed (Enable) and logged in
  (claude login / codex login — code mode reuses that saved credential).
- Chat path no longer auto-downloads: getProvisionedEnginePath() returns the
  enabled engine or throws a clear "enable it in Settings → Code Mode" error, so
  there's never a surprise mid-conversation download. getAgentLaunchSpec is sync
  again.
- Agent status: `installed` now means "engine provisioned" (downloaded), driving
  the Enable/Ready state; the new-session dialog shows "Enable in Settings" and
  disables un-enabled agents. Dropped the dead PATH-probing for a global CLI.

Verified: empty cache -> status installed=false and the chat path throws the
enable-in-Settings error (no download); core, renderer, and main typecheck/build;
no new lint errors.
After a successful provision, remove any other version dirs (and their .meta) for
that agent so old ~200 MB engines don't accumulate across version bumps. Best-effort;
never fails a good install. Verified: a planted stale version dir + meta are both
removed after provisioning the current version.
Provisioning state lived in the row component, which unmounts when the Settings
dialog closes — so reopening mid-download showed the Enable button again even though
the download was still running in the main process. Move provisioning state to a
module-level store with one persistent listener on codeMode:engineProgress, so a row
remounting (dialog reopened) reflects the live % and resolves to Ready on completion.
…nable flash)

On successful provision the in-flight flag was cleared before the async status
refresh completed, so the row briefly (or until reopen) showed the Enable button
again. Await the status refresh before clearing the flag so it transitions directly
to Ready.
Awaiting the status refresh wasn't enough — setStatus re-renders the parent
separately from the row, leaving a window where the in-flight flag was cleared but
the status prop was stale, so the row flashed/stuck on the Enable button until
reopen. Track just-enabled agents in a module-level set and treat them as installed
immediately; loadStatus still syncs the real status in the background.
#1 (the gh/git "command not found" in packaged builds): GUI/Finder launches inherit
launchd's stripped PATH (/usr/bin:/bin:...), so tools the engine spawns — gh, git,
rg, bash — fail even though they work from a terminal (e.g. Homebrew's
/opt/homebrew/bin/gh). Probe the user's login-shell PATH and graft it onto the
engine's env before spawn (shell-env.ts; no-op on Windows / probe failure).

#2: add a 60s startup deadline (initialize / session create+load) so a wedged engine
fails with a clear, stderr-enriched error instead of an infinite "(pending...)".
Overridable via ROWBOAT_ACP_STARTUP_TIMEOUT_MS. Manager now disposes the client on
startup failure so the spawned adapter doesn't leak.

Verified: getAgentLaunchSpec's env.PATH now includes /opt/homebrew/bin (where gh
lives); core builds; no new lint errors.
Revert to the explicit comment-out approach for osxSign/osxNotarize: uncomment them
(with APPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID) for a signed release build.
The repo's forge.config ships with osxSign/osxNotarize enabled (release-ready).
Developers comment them out locally for unsigned test builds and don't commit that.
…ively

The allowBuilds entries were left as "set this to true or false" placeholders, so
`pnpm install` / the pre-build deps check aborted with ERR_PNPM_IGNORED_BUILDS and
`npm run package` failed. Set them to true (and add node-pty, used by the code-mode
embedded terminal) so build scripts are approved and packaging works without a manual
`pnpm approve-builds`.
@arkml
arkml merged commit 2ddec07 into dev Jun 17, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants